13  Programmazione funzionale

13.1 Classe per la formulazione di Criteri

13.1.1 Classe anonima

public void printPersone(TestaPersonaI pred) {
    for (Persona p : lista) {
        if (pred.test(p)) {
            p.printPersona();
        }
    }
}

Comunita cs = new Comunita();
cs.printPersone(new TestaPersonaI() {
    public boolean test(Persona p) {
        return p.getEta() >= 18 & p.getEta() <= 25;
    }
});

Questa soluzione richiede meno codice di creare un metodo per ogni possibile query ma la sintassi si appesantisce considerando che TestaPersonaI contiene solo un metodo. Non posso fare inoltre l’overriding di questa classe e se ho bisogno della stessa classe in un’altra parte del codice, ricado nel copia incolla.

In alternativa si possono usare lambda-espressioni tramite una interfaccia funzionale (= interfacce vincolate ad avere una sola operazione).

13.2 Lambda espressione

In Java, un’espressione lambda fornisce un modo per creare una funzione anonima che può essere passata come argomento o restituita in uscita nei metodi.

La sintassi è la seguente:

(Lista degli argomenti) -> { istruzioni; }
(Lista degli argomenti) -> Espressione
(Persona p) -> p.getEta() >= 18 && p.getEta() <= 25

oppure

p.getEta() >= 18 && p.getEta() <= 25 // il compilatore fa inferenza, quindi posso omettere la parte sinistra

Si possono usare diverse interfacce funzionali standard di java.util.function, ad esempio:

interface Predicate<T> {
    boolean test(T t);
}

Utilizzata in questo modo:

import java.util.LinkedList;
import java.util.List;
import java.util.function.Predicate;

class Comunita {
    private List<Persona> lista = new LinkedList<>();
    public void printPersoneConPiuDi(int eta) {
        for (Persona p : lista) { 
            if (p.getEta() >= eta) p.printPersona();
        }
    }
    
    private void add(Persona p){lista.add(p);}
    
    public void printPersoneConPredicato(Predicate<Persona> pred) {
        for (Persona p : lista) { 
            if (pred.test(p)) p.printPersona(); 
        }
    }
    public static void main(String args[]){
        Comunita c = new Comunita();
        c.add(new Persona("AA", 22, "a@b")); 
        c.add(new Persona("AB", 12, "a@a"));
        c.add(new Persona("CA", 21, "c@b")); 
        c.add(new Persona("AA", 29, "cc@b"));
        c.printPersoneConPredicato(p -> p.getEta() >= 18 && p.getEta() <= 25);
    }
}

Si possono anche definire delle azioni alternative da compiere.

L’interfaccia Consumer contiene il metodo void accept(T t), che applica la funzione passata come parametro sull’argomento della chiamata

public void elaboraPersone(Predicate<Persona> pred, Consumer<Persona> blocco) {
for (Persona p : lista) {
    if (pred.test(p))
        blocco.accept(p); // p.printPersona();
    }
}

In alternativa, se l’azione da compiere deve restituire un valore, si utilizza l’interfaccia Function<T, R> che contiene l’operazione R apply(T t), che applica la funzione passata come parametro sull’argomento della chiamata

public void elaboraPersoneConFunction(Predicate<Persona> pred, Function<Persona, String> mapper,    Consumer<String> blocco) {
    for (Persona p : lista) {
        if (pred.test(p)) {
            String dati = mapper.apply(p);
            blocco.accept(dati);
        }
    }
}

public static void main(String args[]) {
    Comunita c = new Comunita();
    c.add(new Persona("AA", 22, "a@b"));
    c.add(new Persona("AB", 12, "a@a"));
    c.add(new Persona("CA", 21, "c@b"));
    c.add(new Persona("AA", 29, "cc@b"));
    c.elaboraPersoneConFunction( 
        p -> p.getEta() >= 18 && p.getEta() <= 25,
        p -> p.getNome(),
        nomex -> System.out.println(nomex.toLowerCase())
    );
}

13.3 Operazioni aggregate

public static void main(String args[]) {
    Comunita c = new Comunita();
    c.add(new Persona("AA", 22, "a@b"));
    c.add(new Persona("AB", 12, "a@a"));
    c.add(new Persona("CA", 21, "c@b"));
    c.add(new Persona("AA", 29, "cc@b"));
    c.lista
        .stream() // ottiene lo stream
        .filter(p -> p.getEta() >= 18 && p.getEta() <= 25) // filtra in base a un predicato
        .map(p -> p.getNome()) // mappa un oggetto su un valore specifico
        .forEach(nomeX -> System.out.println(nomeX)); // esegue la azione su ogni oggetto mappato
}

Una pipeline è una sequenza di operazioni aggregate (per esempio .filter,.map, .forEach).

La sorgente della pipeline è una collezione o un canale I/O.

Le operazioni intermedie della pipeline producono uno stream, ovvero una sequenza di elementi che serve a veicolare valori da una sorgente attraverso una pipeline (e non a conservarli come una collezione), nell’esempio:

  • .stream crea lo stream dalla lista

  • .filter restituisce un nuovo stream costituito dagli elementi di età compresa tra 18 e 25 (quelli che soddisfano la lambda espressione)

L’operazione terminale produce il risultato finale, che non è uno stream, ma è un tipo primitivo, una collezione, nessun valore, ecc…, ed è il .forEach nel nostro esempio.

Java prevede diverse operazioni terminali o di riduzione (average, sum, min, max e count) che restituiscono un solo valore che combina gli elementi dello stream o anche intere collezioni. In aggiunta alle operazioni menzionate ci sono operazioni di riduzione general-purpose, come i metodi Stream.reduce e Stream.collect:

13.3.1 Stream.reduce

int sommaEta() {
    int somma = lista
                    .stream()
                    .filter(p -> p.getNome().toUpperCase().charAt(0) == 'A')
                    .map(p -> p.getEta())
                    .reduce(0, (a, b) -> a + b); // definizione ricorsiva della somma di elementi di uno stream
    // in alternativa
    //.mapToInt(p->p.getEta())
    //.sum();
    return somma;
}

La Stream.reduce richiede due argomenti:

  • elemento identità: rappresenta il valore iniziale della riduzione o il risultato di default in caso di stream vuoto (nell’esempio della somma, la identità è 0)

  • accumulatore: funzione binaria che richiede il risultato parziale corrente e il prossimo elemento dello stream e restituisce un nuovo risultato parziale (nell’esempio si usa una lambda espressione per la somma di interi che restituisce un intero: (a, b) -> a + b).

La funzione accumulatore restituisce un nuovo valore ogni volta che elabora un nuovo elemento dello stream e riduce lo stream a un elemento complesso. Se l’elemento complesso fosse a sua volta una collezione, le prestazioni del reduce sarebbero compromesse perchè dovrebbe aggiungere elementi a una nuova collezione ogni volta che un elemento è aggiunto allo stream.

Si usa, in alternativa, Stream.collect per aggiornare una collezione esistente.

13.3.2 Stream.collect

Facciamo un esempio: calcolare la media prevede la creazione di un oggetto complesso che colleziona il numero di valori nello stream e la somma dei valori.

Si definisce la classe Media che implementa l’interfaccia IntConsumer, che include l’operazione void accept(int x):

class Media implements IntConsumer {
    private int totale = 0; 
    private int contatore = 0;
    
    public double average() { 
        return contatore > 0 ? ((double) totale)/contatore : 0; 
    }
    
    public void accept(int i) { totale += i; contatore++; }
    
    public void combine(Media altro) {
        totale += altro.totale; contatore += altro.contatore;
    }
}

double mediaEta(){
    Media media = lista
                    .stream()
                    .filter(p -> p.getNome().toUpperCase().charAt(0) == 'A')
                    .map(p -> p.getEta())
                    .collect(Media::new, Media::accept, Media::combine);
    return media.average();
}

Stream.collect ha tre argomenti:

  • factory method: costruisce nuove istanze del contenitore del risultato

  • accumulatore: funzione che incorpora un elemento dello stream in un contenitore del risultato

  • combinatore: funzione che fonde il contenuto di due contenitori di risultato (se computato in parallelo)

13.4 Computazione in parallelo

Per creare uno stream in parallelo si può chiamare Collection.parallelStream

Ad esempio, calcolare l’età media delle persone di età compresa tra 18 e 25 in parallelo:

double mediaEta(){
    Media media = lista
    .parallelStream() // stream in parallelo
    .filter(p->p.getNome().toUpperCase().charAt(0)=='A')
    .map(p->p.getEta())
    .collect(Media::new, Media::accept, Media::combine);
    return media.average();
}

13.5 Riduzione concorrente

Ad esempio, raggruppiamo dei membri per Nome chiamando una collect che riduce lista in una Map: ```Java Map<String, List> perNome() { Map<String, List> group = lista.stream().collect(Collectors.groupingBy(Persona::getNome)); // la chiave del Map dipende da questo argomento, il valore dipende dal tipo degli elementi che finiscono nello stream return group; }

Map<String, List> perNomeConcurrent(){ Map<String, List> group = lista.parallelStream().collect(Collectors.groupingByConcurrent(Persona::getNome)); return group; }